「廠商都出官方 SDK 了,為什麼還要多包一層 Omnipay 驅動套件,不是直接用官方 SDK 比較快?」
這是一個很實際的問題——如果你只服務自己的專案,直接用官方 SDK 確實比較快。但 omnipay-ecpay 這種驅動套件存在的理由,是讓任何已經在用 Omnipay 的專案,只要換一個 Gateway 名稱,就能無痛接上綠界,不用重新學一套 API。今天要看的是,這件事具體是怎麼做到的。
HasECPay 這個 Trait 怎麼包裝綠界官方 SDK 的 Factory
HasECPay:一個只有 15 行,卻被多個類別共用的 Traittrait HasECPay
{
private $globalBackup = [];
protected function factory($request, $class)
{
$factory = new Factory([
'hashKey' => $request->getHashKey(),
'hashIv' => $request->getHashIV(),
]);
return $factory->create($class);
}
}
這裡的 Factory 是綠界官方 ecpay/sdk 套件提供的 Ecpay\Sdk\Factories\Factory,負責依名稱建立官方 SDK 裡的各種服務類別(例如驗簽、發送 HTTP 請求的服務)。HasECPay 把「怎麼建立官方服務物件」這件事收斂成一個 factory() 方法,讓需要呼叫綠界 API 的 Request 類別(FetchTransactionRequest、RefundRequest、VoidRequest)都能重複使用,不用每個類別各自寫一次 new Factory(...)。
FetchTransactionRequest 用它來查詢交易:
protected function getTradeInfo($data)
{
return $this->factory($this, 'PostWithCmvVerifiedEncodedStrResponseService')
->post($data, $this->getEndpoint());
}
RefundRequest 用它來送出退款動作:
protected function doAction($data)
{
return $this->factory($this, 'PostWithCmvEncodedStrResponseService')
->post($data, 'https://payment.ecpay.com.tw/CreditDetail/DoAction');
}
這兩個服務名稱看起來只差一個「Verified」,很容易被誤讀成「一個在送出前驗證、一個在送出後驗證」。實際去讀官方 SDK 的 Factory::create() 原始碼才發現:兩者都會幫送出的請求算 CheckMacValue(都用同一個 CheckMacValueRequest),差別只在回應要不要驗簽——PostWithCmvVerifiedEncodedStrResponseService 多包了一層 VerifiedEncodedStrResponse,會在解析回應資料後呼叫 CheckMacValueService::verify(),驗簽失敗就丟出 RtnException(106)(訊息是 CheckMacValue verify failed);PostWithCmvEncodedStrResponseService 用的是不做驗證的 EncodedStrResponse,單純把回應字串解析成陣列就結束。FetchTransactionRequest 查詢交易時綠界會回傳完整交易資料,值得驗簽;RefundRequest 送出的是一個動作指令,回應相對單純,這大概是為什麼兩者選用不同服務的原因(這點是從程式碼行為推論,不是官方文件明講的設計理由)。
翻過 src/Message/ 底下的檔案,官方 SDK 至少在四個地方被直接使用:
HasECPay:Ecpay\Sdk\Factories\Factory(建立服務物件)RefundRequest、FetchTransactionRequest:Ecpay\Sdk\Exceptions\RtnException——這是官方 SDK 裡通用的錯誤例外,不是「退款/查詢失敗」專屬,讀過 SDK 原始碼後確認它總共在 8 種情境下被拋出:CURL 連線失敗、AES 加解密失敗、CheckMacValue 產生/驗證失敗等,涵蓋的是 SDK 內部運作失敗,不是綠界業務邏輯上的失敗(例如餘額不足這類業務失敗,是透過回應裡的 RtnCode/RtnMsg 欄位表達,不會讓 SDK 丟例外)PurchaseRequest:Ecpay\Sdk\Services\UrlService::ecpayUrlEncode()——讀過原始碼後確認具體做法是先 urlencode()、轉小寫,再把幾個特定的百分號跳脫字元(%2d/%5f/%2e/%21/%2a/%28/%29)換回對應的字面字元(-/_/./!/*/(/)),刻意對齊 .NET 的 URL 編碼慣例——這正是「廠商規格不是通用標準,套件要照抄」的一個具體例子CompletePurchaseRequest:Ecpay\Sdk\Response\VerifiedArrayResponse(官方提供、已經驗證過簽章的回應物件)這代表這個套件不是重新造輪子去重寫簽章驗證、URL 編碼這些邏輯,而是信任官方 SDK 處理這些細節,自己只負責把官方 SDK 的呼叫方式,轉譯成 Omnipay 期待的 Gateway/RequestInterface/ResponseInterface 樣子。
包裝官方 SDK 不是只有「呼叫它」,更關鍵的一步是把它丟出來的例外,翻譯成呼叫端看得懂、預期得到的型別。CompletePurchaseRequest 驗證背景通知簽章的地方就是一個具體例子:
private function checkMacValue($data)
{
try {
$this->factory($this, VerifiedArrayResponse::class)->get($data);
} catch (Exception $e) {
throw new InvalidRequestException($e->getMessage(), $e->getCode(), $e);
}
return $data;
}
$this->factory(...) 透過官方 Factory 建立 VerifiedArrayResponse 這個官方服務物件,呼叫它的 get($data) 做簽章驗證——驗證失敗時,官方 SDK 丟出的是它自己定義的例外類型。但這段程式碼用一個寬鬆的 catch (Exception $e) 全部接住,重新包成 Omnipay 生態系認得的 Omnipay\Common\Exception\InvalidRequestException 再丟出去(保留原始例外訊息跟前一個例外物件 $e,方便追蹤根因)。
這就是為什麼昨天 Day 04 提到的測試 testInvalidCheckMacValue,斷言抓到的是 InvalidRequestException,而不是綠界官方 SDK 自己的例外類型——呼叫這個驅動套件的人,永遠只需要認得 Omnipay 的例外體系,不需要知道底層在用哪家廠商的 SDK,更不需要知道那家 SDK 定義了什麼例外類型。這才是「包裝」真正在做的事:不只是少寫幾行 new Factory(...),而是把兩套完全不相干的錯誤處理慣例接成一套。
❌ 反例:每個類別各自建立官方 SDK 服務,規則散落各處
class RefundRequest extends AbstractRequest
{
protected function doAction($data)
{
$factory = new Factory([
'hashKey' => $this->getHashKey(),
'hashIv' => $this->getHashIV(),
]);
$service = $factory->create('PostWithCmvEncodedStrResponseService');
return $service->post($data, 'https://payment.ecpay.com.tw/CreditDetail/DoAction');
}
}
class FetchTransactionRequest extends AbstractRequest
{
protected function getTradeInfo($data)
{
// 同樣的 Factory 建立邏輯,再寫一次
$factory = new Factory([
'hashKey' => $this->getHashKey(),
'hashIv' => $this->getHashIV(),
]);
$service = $factory->create('PostWithCmvVerifiedEncodedStrResponseService');
return $service->post($data, $this->getEndpoint());
}
}
✅ 正例:建立官方服務物件的規則收斂到一個 Trait
trait HasECPay
{
protected function factory($request, $class)
{
$factory = new Factory([
'hashKey' => $request->getHashKey(),
'hashIv' => $request->getHashIV(),
]);
return $factory->create($class);
}
}
反例不是寫不出來,問題是:如果官方 SDK 哪天改了 Factory 的建構參數(例如多要求一個設定值),反例要改三、四個地方;正例只要改 HasECPay 這一處。驅動套件的價值之一,就是把「怎麼跟官方 SDK 打交道」的規則收斂到一個地方,讓上層的 Request 類別只需要關心「這次要打哪支 API、帶什麼參數」。
值得注意的是,這個套件同時服務兩種讀者:
$gateway->purchase(...)->send()),完全不需要知道底層在跟 Ecpay\Sdk\Factories\Factory打交道這也是為什麼這種「介面轉譯層」的程式碼,通常比純業務邏輯更需要測試保護——它的正確性同時綁在兩份外部契約上(官方 SDK 的行為、Omnipay 的介面約定),任何一邊改版都可能讓轉譯出錯,卻不會馬上被兩邊任何一方的測試套件抓到,只有這個驅動套件自己的測試才擋得住。
如果你的專案也在包裝一個第三方 SDK,你有沒有把「建立/設定 SDK 物件」的邏輯收斂到一個地方,還是散落在每個呼叫它的地方各寫一次?下次官方 SDK 改版時,你會需要改幾個檔案?
HasECPay trait 用官方 Factory 建立官方 SDK 服務物件,被多個 Request 類別共用Factory、RtnException、UrlService、VerifiedArrayResponse)明天回到 README 這個老問題:如果連套件維護者自己都沒把功能寫進 README,新使用者要怎麼知道這個套件實際支援哪些付款方式?測試案例能不能真的補上這個缺口?